<?php

define('STORAGE_PATH', 'F:\\tmp\\jim2');

/**
 * Cache
 */
class Cache
{
  /**
   * Tag a key
   * @param string $tag
   * @param string $key
   */
  static public function tagKey($tag, $key)
  {
    if ( xcache_isset($tag) )
    {
      $keys = xcache_get($tag);
    }

    $keys[] = $key;
    xcache_set($tag, $keys);
  }

  /**
   * Delete by cache
   * @param string $tag
   */
  static public function deleteByTag($tag)
  {
    if ( xcache_isset($tag) )
    {
      $keys = xcache_get($tag);

      foreach ($keys as $key)
      {
        xcache_unset($tag . '/' . $key);
      }

      xcache_unset($tag);
    }
  }

  /**
   * Save in cache
   * @param string $key
   * @param mixed $value
   * @param int $ttl
   */
  static public function set($key, $value, $ttl = 0)
  {
    xcache_set($key, $value, $ttl);
  }

  /**
   * Fetch a key from cache
   * @param string $key
   * @return mxied
   */
  static public function get($key)
  {
    return xcache_get($key);
  }

  /**
   * Check existed key
   * @param string $key
   * @return bool
   */
  static public function exists($key)
  {
    return xcache_isset($key);
  }
}

/**
 * Storage
 */
class Storage
{

  const WAIT_FACTOR = 10;

  const TIMEOUT = 1;

  /**
   * Read a file
   * @param string $fileName
   * @return string|false
   */
  static public function read($fileName)
  {
    return @file_get_contents(STORAGE_PATH . DIRECTORY_SEPARATOR . $fileName);
  }

  /**
   * Write a file
   * @param string $fileName
   * @param string $data
   * @return bool
   */
  static public function write($fileName, $data)
  {
    $filePath = STORAGE_PATH . DIRECTORY_SEPARATOR . $fileName;

    if ( file_exists($filePath) )
    {
      for ($i = 0; $i < self::WAIT_FACTOR; $i++)
      {
        if ( ! is_writable($filePath) )
        {
          sleep(self::TIMEOUT);
        }
        else
        {
          break;
        }
      }

      if ($i == self::WAIT_FACTOR)
      {
        return false;
      }
    }
    return file_put_contents($filePath, $data, LOCK_EX);
  }

  /**
   * Delete a file
   * @param string $fileName
   * @return bool
   */
  static public function delete($fileName)
  {
    return unlink(STORAGE_PATH . DIRECTORY_SEPARATOR . $fileName);
  }
}

/**
 * Index
 */
class Index
{

  /**
   * Root name
   * @var string
   */
  private $root;

  /**
   * Root index
   * @param string $root
   */
  public function __construct($root)
  {
    $this->root = $root;
    $this->load();
  }

  /**
   * Load index in memory
   */
  private function load()
  {
    $rootPath = './' . $this->root;

    if ( ! Cache::exists($rootPath) )
    {
      $content = Storage::read('_' . $this->root);

      $indexNames = array ();

      if ($content !== false)
      {
        $index = json_decode($content, true);

        foreach ($index as $name => $keys)
        {
          $indexKeys = array();

          foreach ($keys as $unique => $values)
          {
            $indexKeys[] = $unique;
            Cache::set($rootPath . '/' . $name . '/' . $unique, $values);
          }

          $indexNames[] = $name;
          Cache::set($rootPath . '/' . $name, $indexKeys);
        }
      }
      else
      {
        $indexNames[] = 'id';
      }

      Cache::set($rootPath, $indexNames);
    }
  }

  /**
   * Save index in file system
   */
  public function save()
  {
    $rootPath = './' . $this->root;
    $indexes = array ();

    if ( Cache::exists($rootPath) )
    {
      $indexNames = Cache::get($rootPath);

      foreach ($indexNames as $name)
      {
        if ( Cache::exists($rootPath . '/' . $name) )
        {
          $indexes[$name] = array ();

          $keys = Cache::get($rootPath . '/' . $name);

          foreach ($keys as $key)
          {
            $indexes[$name][$key] = Cache::get($rootPath . '/' . $name . '/' . $key);
          }
        }
      }

    }

    $content = json_encode($indexes);
    Storage::write('_' . $this->root, $content);
  }

  /**
   * Drop index from specific path
   * @param string $path
   * @param string $index
   * @param string $value
   */
  public function dropIndexFromPath($path, $index, $value)
  {
    $indexName = $path . '/' . $index;

    $keys = Cache::get($indexName);

    $position = array_search($value, $keys);

    if ($position !== false)
    {
      unset ($keys[$position]);

      if ( empty ($keys) )
      {
        if ($path != '.')
        {
          xcache_unset($indexName);

          $value = $index;
          $index = basename($path);
          $path = dirname($path);

          if ($path != $this->root)
          {
            $this->dropIndexFromPath($path, $index, $value);
          }
        }
      }
      else
      {
        Cache::set($indexName, $keys);
      }
    }
  }

  /**
   * Drop index
   * @param string $index
   * @return bool
   */
  public function dropIndex($index)
  {
    $indexPath = './' . $this->root . '/' . $index;

    if ( Cache::exists($indexPath) )
    {
      Cache::deleteByTag($indexPath);
      $this->dropIndexFromPath('.', $this->root, $index);

      return true;
    }

    return false;
  }
}

/**
 * Object collection
 */
class Collection
{

  /**
   * Index object
   * @var Index
   */
  private $index;

  /**
   * Initialize index
   * @param string $filePath
   */
  private function init()
  {
    if ( ! Cache::exists('_VERSION') )
    {
      $content = Storage::read('_rev');

      if ($content !== false)
      {
        Cache::set('_VERSION', intval($content));
      }
      else
      {
        Cache::set('_VERSION', 0);
      }
    }
  }

  /**
   * Update collection version
   */
  private function commit()
  {
    Storage::write('_rev', xcache_inc('_VERSION'));
  }

  /**
   * Normalize identifier
   * @param string $id
   * @return string
   */
  private function normalizeId($id)
  {
    $id = preg_replace('/[^\wа-я]+/ui', '-', $id);
    $id = trim($id, '-');
    return mb_strtolower($id, 'UTF-8');
  }

  /**
   * Constructor init configuration
   * @param string $objectName
   */
  public function __construct()
  {
    $this->index = new Index('index');
    $this->init();
  }

  /**
   * Add document properties
   * @param array[string]mixed $properties
   * @return array(int,array[string]mixed)
   */
  public function put($properties)
  {
    if ( ! array_key_exists('id', $properties) )
    {
      $properties['id'] = md5( uniqid() );
    }
    else
    {
      $properties['id'] = $this->normalizeId($properties['id']);
    }

    $id = $properties['id']; // unique key
    $properties['_rev'] = time();

    // try merge with existed document...
    if ( Cache::exists('./documents/' . $id) )
    {
      $document = Cache::get('./documents/' . $id);

      if ($document['_rev'] > $properties['_rev'])
      {
        return array ($document['_rev'], false); //---------------- RETURN
      }
      else
      {
        $properties = array_merge($document, $properties);
      }
    }

    // save in cache...
    Cache::set('./documents/' . $id, $properties, 3600);

    // save in file system...
    $data = json_encode($properties);
    Storage::write($id, $data);

    // reindex document and save in file system
    $this->reindex($id, $properties, $document);

    // save version
    $this->commit();
    
    return array ($properties['_rev'], $properties);
  }

  /**
   * Fetch document by id
   * @param string $id
   * @return array[string]mixed
   */
  public function get($id)
  {
    if ( Cache::exists('./documents/' . $id) )
    {
      return Cache::get('./documents/' . $id);
    }
    else
    {
      $data = Storage::read($id);

      if ($data !== false)
      {
        $data = json_decode($data, true);
        Cache::set('./documents/' . $id, $data);

        return $data;
      }

      return false;
    }
  }

  /**
   * Delete document from collection
   * @param string $id
   */
  public function delete($id)
  {
    $document = $this->get($id);

    if ($document !== false)
    {
      $indexes = Cache::get('./index');

      foreach ($indexes as $index)
      {
        $fields = explode('+', $index);
        
        $documentIndex = $this->getIndexForDocument($document, $fields);

        $this->index->dropIndexFromPath('./index/' . $index, $documentIndex, $id);
      }

      $this->index->save();

      // drop document
      xcache_unset('./documents/' . $id);
      Storage::delete($id);

      // save version
      $this->commit();
    }
  }

  //-------------------------------- INDEXES LOGIC

  /**
   * Get index for document
   * @param array[string]mixed $document
   * @param array[int]string $fields
   * @return string|false
   */
  private function getIndexForDocument(&$document, $fields)
  {
    foreach ($fields as $field)
    {
      if ( array_key_exists($field, $document) )
      {
        $index[] = $document[$field];
      }
      else
      {
        return false; //------------------ RETURN
      }
    }

    return implode('+', $index);
  }

  /**
   * Reindex document
   * @param string $id
   * @param array[string]mixed $newDocument
   * @param array[string]mixed $lastDocument
   */
  private function reindex($id, &$newDocument, &$lastDocument)
  {
    $indexes = Cache::get('./index');

    $totalChanges = 0;

    foreach ($indexes as $index)
    {
      $fields = explode('+', $index);

      $newIndex = $this->getIndexForDocument($newDocument, $fields);

      if ($newIndex !== false)
      {
        if ( ! empty ($lastDocument) )
        {
          $lastIndex = $this->getIndexForDocument($lastDocument, $fields);

          if ($newIndex != $lastIndex)
          {
            $this->index->dropIndexFromPath($index, $lastIndex, $id);
          }
          else
          {
            continue; //------------------- CONTINUE
          }
        }

        Cache::tagKey('./index/' . $index, $newIndex);
        Cache::tagKey('./index/' . $index . '/' . $newIndex, $id);

        $totalChanges++;
      }
    }

    if ($totalChanges > 0)
    {
      $this->index->save();
    }
  }

  /**
   * Create index for documents
   * @param string|array[int]string $index
   */
  public function createIndex($index)
  {
    if ( Cache::exists('./index/id') )
    {
      $ids = Cache::get('./index/id');

      if ( ! is_array($index) )
      {
        $index = array ($index);
      }

      $totalChanges = 0;

      foreach ($index as $indexRule)
      {
        if ( ! Cache::exists('./index/' . $indexRule) )
        {
          $indexes = array ();
          $fields = explode('+', $indexRule);

          foreach ($ids as $id)
          {
            $documentIndex = $this->getIndexForDocument($this->get($id), $fields);

            if ($documentIndex !== false)
            {
              $indexes[] = $documentIndex;
              Cache::tagKey('./index/' . $indexRule . '/' . $documentIndex, $id);
            }
          }

          if ( ! empty ($indexes) )
          {
            Cache::set('./index/' . $indexRule, $indexes);
            Cache::tagKey('./index', $indexRule);

            $totalChanges++;
          }
        }
      }

      if ($totalChanges > 0)
      {
        $this->index->save();
      }
    }
  }

  /**
   * Delete an index
   * @param string|array[int]string $index
   */
  public function deleteIndex($index)
  {
    if ( ! is_array($index) )
    {
      $index = array ($index);
    }

    $totalChanges = 0;

    foreach ($index as $indexRule)
    {
      if ( $this->index->dropIndex($index) )
      {
        $totalChanges++;
      }
    }

    if ($totalChanges > 0)
    {
      $this->index->save();
    }
  }

}